#! usr/bin/python3
# -*- coding:utf-8 -*-
"""
    作者：王楠
    功能：根据循环数据进行线性拟合，根据拟合的结果推算循环到寿命的循环周数，并进行图形的绘制
    版本:1.0：读取excel数据并输出
    版本:2.0: 线性拟合类 class LinearRegression() 最小二乘法线性拟合算法和实现
    版本:3.0：读取excel处理改写成类 class Excel_Date()进行方法和细节的封装
    版本:4.0：图形界面 分为四大部分
            1、page_date：数据导入，操作按钮等button等
                1、标称容量的输入，用于计算保持率，并在浏览数据EXCEL前确认标称容量是否输入
                2、文件浏览，通过TK.filedialog.askopenfilename(title='选择文件')获取文件路径
                3、判断文件是否是excel文件，通过os.path得到文件后缀，根据文件后准判断。
                4、输入拟合周数，默认0为拟合全部数据，否则根据输入的数，从最新的周全往前拟合数据的周数数据
                    如100，即对最新（近）的100周数据进行拟合，并根据此进行预测
                5、使用说明，主要对excel文件内循环数据存放格式的说明。
            2、page_ifo：根据导入的数据信息展示循环信息，根据拟合展示拟合信息
                数据信息：主要显示最大容量，目前循环周数，保持率等，根据Excel_date类实现
                拟合信息：1、拟合所用的周数
                        2、拟合线性拟合公式 y = A * x + B
                        3、线性拟合的R值
                        4、根据拟合所预测的循环周数
                信息通过LinearRegression类中字符串变量self.ifo记录存贮
                预测通过LinearRegression类中predict()实现计算预测
            3、page_draw1：根据原始数据绘制散点图的区域，占位区域，即空白容器
                1、根据浏览数据的的原始循环数据，进行散点图的绘制，新建page_draw放置在page_draw1内,
                    调用__plot()函数在page_draw上绘制图形
            4、page_regre_draw1：根据原始数据和拟合绘制散点图和拟合线
    版本:5.0：绘制图形
            1、图形绘制函数__plot(self, x, y, page, A=0.0, B=0.0)
            2、根据传入的x、y数据，在page上进行图形的绘制。
            3、根据关键字参数 A=0.0, B=0.0，传入拟合数据（斜率和截距），默认为0，则不进行拟合曲线的回执
    版本:5.1：细节完善
            1、增加循环号和容量标题的自定义，默认是充放循环号和放电容量(Ah)。通过修改和支持不同的原始Excel文件快速导入，不必修改每一个Excel文件
            2、增加异常处理：
                1、Excel文件的列标题与默认不匹配或者输入有误时，抛出异常，弹出提醒message
                2、Excel文件不可读取时，文件损坏或被加密，抛出异常，弹出提醒message
                3、导入的文件，在class Excel_Date()类中控制，不要最后一个点的数据，以防止最后一个点是为完全放电的容量值，或者是0，造成数据异常
                4、数据文件中有空单元格造成无法拟合的异常。抛出错误提示修改删除原始文件中的空白行。
            3、增加说明按钮，详细说明使用方法，附联系方式。
            4、版权声明标志： '© 2020 WangNan,All rights reserved.'
            5、增加page_destory()函数，用于在打导入新文件或者导入新文件出现异常时快速的删除已经绘制存在的图。
            

"""
# 处理Excel需要的库
import pandas as pd
import numpy as np
import tkinter as TK
import tkinter.filedialog
import os
from tkinter import messagebox
import time
# 创建画布需要的库
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
# 创建工具栏需要的库
from matplotlib.backends.backend_tkagg import NavigationToolbar2Tk
# 快捷键需要的模块
from matplotlib.backend_bases import key_press_handler
# 导入绘图需要的模块
import matplotlib.pyplot as plt


class LinearRegression():
    """
    线性拟合类，根据x、y数据进行线性拟合返回拟合的信息，斜率和截距以及拟合公式等
    根据标称容量和最大容量对循环进行预测
    """
    def __init__(self, x, y, nominal_capacity, max_capaciyt):
        self.A = 0.0  # 斜率
        self.B = 0.0  # 截距
        self.x = np.array(x)  # x数据
        self.y = np.array(y)  # y数据
        self.y_pred = None
        self.sse = 0
        self.mse = 0
        self.rmse = 0
        self.mae = 0
        self.ssr = 0
        self.sst = 0
        self.r2 = 0
        self.nominal_capacity = nominal_capacity
        self.max_capaciyt = max_capaciyt
        self.ifo = ''
        self.solve()

    def solve(self):
        """
        拟合函数，返回拟合公式
        """
        x = self.x
        y = self.y
        self.A = sum((x - x.mean()) * (y - y.mean())) / sum((x - x.mean())**2)
        self.B = y.mean() - self.A * x.mean()
        self.y_pred = np.array(x * self.A + self.B)
        # print('拟合公式为：y = %0.3f x + %0.3f' % (self.A, self.B))
        self.ifo += ('拟合公式为：y = %0.3f x + %0.3f。\n' % (self.A, self.B))
        """
        拟合质量
        """
        self.sse = sum((self.y - self.y_pred)**2)
        self.mse = sum((self.y - self.y_pred)**2) / len(self.x)
        self.rmse = np.sqrt(self.mse)
        self.mae = np.mean(np.abs(self.y - self.y_pred))
        self.ssr = sum((self.y_pred - np.mean(self.y))**2)
        self.sst = sum((self.y - np.mean(self.y))**2)
        # R平方值
        self.r2 = self.ssr / self.sst
        # print('拟合R值为：R = %0.5f' % self.r2)
        self.ifo += ('拟合R值为：R = %0.5f。\n' % self.r2)

    def predict(self):
        """
        根据标称容量和最大容量分别预测80%时的循环周数
        """
        capacity80_no = self.nominal_capacity * 0.8
        capacity80_max = self.max_capaciyt * 0.8
        # print('预计容量到标称容量的80%%时循环周数为%d周' %
        #       (self.get_cycle_number(capacity80_no)))
        self.ifo += ('预计容量到最大容量的80%%时循环周数为：%d 周。\n' %
                     (self.get_cycle_number(capacity80_max)))
        self.ifo += ('预计容量到标称容量的80%%时循环周数为：%d 周。\n' %
                     (self.get_cycle_number(capacity80_no)))
        # print('预计容量到最大容量的80%%时循环周数为%d周' %
        #       (self.get_cycle_number(capacity80_max)))

    def get_cycle_number(self, capacity):
        """
        输入容量返回预测的循环周数
        """
        # return (capacity - self.B) / self.A
        return int((capacity - self.B) / self.A)

    def get_capacity(self, cycle):
        """
        输入循环周数预测容量
        """
        return self.A * cycle + self.B


class Excel_date():
    def __init__(self, excel_path, nominal_capacity, A_title, B_title):
        self.date = pd.read_excel(excel_path)
        cycle = list(self.date[A_title])
        capacity = list(self.date[B_title])
        #取倒数第二周，以防最新一周的处于放电状态，不是完全放电的容量
        self.cycle = cycle[:-1]
        self.capacity = capacity[:-1]
        self.nominal_capacity = nominal_capacity
        self.max_capacity = np.max(self.date[B_title])
        self.cycle_latest = self.cycle[-1]
        self.capacity_latest = self.capacity[-1]
        self.cycle_life = self.capacity[-1] / max(self.capacity)
        self.cycle_life1 = self.capacity[-1] / self.nominal_capacity

        self.info = '电芯最高容量：%0.2fAh,现有容量：%0.2fAh,循环：%d周。\n' % (
            self.max_capacity, self.capacity_latest, self.cycle_latest)
        self.info += '目前电芯容量保持率为：%0.2f%% (相对最高容量)。\n' % (self.cycle_life * 100)
        self.info += '目前电芯容量保持率为：%0.2f%% (相对标称容量)。\n' % (self.cycle_life1 * 100)


class Mainpage(object):
    def __init__(self):
        self.win = TK.Tk()
        self.win.withdraw()
        screen_width = self.win.winfo_screenwidth()
        screen_height = self.win.winfo_screenheight()
        self.win.geometry(
            '%sx%s+%s+%s' % (1024, 690, int(
                (screen_width - 1024) / 2), int((screen_height - 800) / 2)))
        self.win.minsize(width=1024, height=690)
        # self.win.iconbitmap('.\\Battery2.ico')
        self.win.title('循环分析拟合预测')
        self.time = TK.StringVar()
        self.gettime()
        self.date_path = TK.StringVar()
        self.date_path.set('')
        self.date_title_A = TK.StringVar()
        self.date_title_A.set('充放循环号')
        self.date_title_B = TK.StringVar()
        self.date_title_B.set('放电容量(Ah)')
        self.regre_num = TK.IntVar()
        self.nominal_capacity = TK.DoubleVar()
        self.max_capaciyt = TK.DoubleVar()
        self.creatmainpage()
        self.win.update()
        self.win.deiconify()
        self.win.protocol("WM_DELETE_WINDOW", self.on_closing)
        self.win.mainloop()

    def creatmainpage(self):
        """
        创建页面函数
        """
        # 创建四个容器
        self.page_wel = TK.Frame(self.win, width=900, height=15)
        self.page_date = TK.Frame(self.win, width=400, height=150)
        self.page_ifo = TK.Frame(self.win, width=500, height=150)
        self.page_draw1 = TK.Frame(self.win, width=445, height=500, bg='white')
        self.page_regre_draw1 = TK.Frame(self.win,
                                         width=445,
                                         height=500,
                                         bg='white')
        self.page_draw = None
        self.page_regre_draw = None

        # 放置容器到win上
        self.page_wel.grid(row=0,
                           column=0,
                           columnspan=2,
                           padx=3,
                           pady=3,
                           stick=TK.E + TK.W + TK.N + TK.S)
        self.page_date.grid(row=1,
                            column=0,
                            padx=5,
                            pady=3,
                            stick=TK.E + TK.W + TK.N + TK.S)
        self.page_ifo.grid(row=1, column=1, padx=5, pady=3, stick=TK.NW)
        self.page_draw1.grid(row=2, column=0, padx=4, pady=2)
        self.page_regre_draw1.grid(row=2, column=1, padx=4, pady=2)

        # 在最高的page_wel放置欢迎和时间
        TK.Label(self.page_wel, text='欢迎使用！',
                 font=("微软雅黑", 12)).grid(row=0,
                                         column=0,
                                         columnspan=1,
                                         padx=3,
                                         stick=TK.W)
        TK.Label(self.page_wel, textvariable=self.time,
                 font=("微软雅黑", 12)).grid(row=0, column=1, stick=TK.W, padx=3)
        # TK.Label(self.page_wel,
        #          text='© 2020 WangNan',
        #          font=("TimesNewRoman", 12),
        #          fg='gray').grid(row=0,
        #                          column=3,
        #                          columnspan=4,
        #                          stick=TK.E,
        #                          padx=120)

        # page_date 上的标签、按钮等定义
        #标称容量
        TK.Label(self.page_date, text='标称容量：', fg='red',
                 font=("微软雅黑", 12)).grid(row=0, column=0)
        TK.Entry(
            self.page_date,
            textvariable=self.nominal_capacity,
            width=40,
        ).grid(row=0, padx=3, column=1)
        TK.Label(self.page_date, text='*此项必输', fg='red',
                 font=("微软雅黑", 10)).grid(row=0, column=2)
        # 循环号和放电容量标题设置
        TK.Label(self.page_date, text='号列标题：', fg='red',
                 font=("微软雅黑", 12)).grid(row=1, column=0)
        TK.Entry(
            self.page_date,
            textvariable=self.date_title_A,
            width=40,
        ).grid(row=1, padx=3, column=1)
        TK.Label(self.page_date, text='*此项必输', fg='red',
                 font=("微软雅黑", 10)).grid(row=1, column=2)
        # 循环号和放电容量标题设置
        TK.Label(self.page_date, text='容量标题：', fg='red',
                 font=("微软雅黑", 12)).grid(row=2, column=0)
        TK.Entry(
            self.page_date,
            textvariable=self.date_title_B,
            width=40,
        ).grid(row=2, padx=3, column=1)
        TK.Label(self.page_date, text='*此项必输', fg='red',
                 font=("微软雅黑", 10)).grid(row=2, column=2)        
        #选取文件
        TK.Label(self.page_date, text='选取文件：',
                 font=("微软雅黑", 12)).grid(row=3, column=0)
        TK.Entry(
            self.page_date,
            textvariable=self.date_path,
            width=40,
        ).grid(row=3, padx=3, column=1)
        TK.Button(self.page_date, text='浏览', width=8,
                  command=self.chooseFile).grid(row=3, column=2)
        #拟合参数
        TK.Label(self.page_date, text='拟合周数：',
                 font=("微软雅黑", 12)).grid(row=4, column=0)
        TK.Entry(
            self.page_date,
            textvariable=self.regre_num,
            width=40,
        ).grid(row=4, padx=3, column=1)
        TK.Button(self.page_date, text='拟合', width=8,
                  command=self.regre).grid(row=4, column=2)
        #使用说明
        TK.Button(self.page_date, text='使用说明', font=("微软雅黑", 10), fg='gray', width=8,
                  command=self.help).grid(row=5, column=0)
        # 信息显示
        TK.Label(self.page_ifo, text='循环信息：',
                 font=("微软雅黑", 10)).grid(row=0, column=0, stick=TK.NW)
        self.tx = TK.Text(self.page_ifo, height=3, width=54)
        self.tx.grid(row=1, column=0, pady=5, padx=10, stick=TK.NW)
        self.tx.config(state='disable')
        TK.Label(self.page_ifo, text='拟合信息：',
                 font=("微软雅黑", 10)).grid(row=2, column=0, stick=TK.NW)
        self.tx1 = TK.Text(self.page_ifo, height=5, width=54)
        self.tx1.config(state='disable')
        self.tx1.grid(row=3, column=0, pady=5, padx=10, stick=TK.NW)
        TK.Label(self.page_draw1, text='数据曲线：',
                 font=("微软雅黑", 12)).grid(row=0, column=0, stick=TK.NW)
        TK.Label(self.page_regre_draw1, text='拟合曲线：',
                 font=("微软雅黑", 12)).grid(row=0, column=1, stick=TK.NW)

    def gettime(self):
        self.time.set(
            time.strftime('%Y{y}%m{m}%d{d} %H{h}%M{f}%S').format(y='年', m='月', d='日', h=':', f=':'))
        self.solve = self.win.after(1000, self.gettime)

    def chooseFile(self):
        """
        获取excel地址返回给 self.date_path
        """
        if self.nominal_capacity.get():
            selectFileName = TK.filedialog.askopenfilename(title='选择文件')
            filename, file_ext = os.path.splitext(selectFileName)
            # self.filenameList = filename.split('/')
            if file_ext == '.xls' or file_ext == '.xlsx':
                self.date_path.set(selectFileName)
                self.tx1.config(state='normal')
                self.tx1.delete(1.0, TK.END)
                self.tx1.config(state='disable')
                self.dateDetaile()
            else:
                messagebox.showinfo(title='警告', message='未选择文件或文件类型不支持！')
                self.date_path.set('')
                self.tx.config(state='normal')
                self.tx.delete(1.0, TK.END)
                self.tx.config(state='disable')
                self.tx1.config(state='normal')
                self.tx1.delete(1.0, TK.END)
                self.tx1.config(state='disable')
                self.nominal_capacity.set(0.0)
                self.page_destory()
        else:
            messagebox.showinfo(title='警告', message='输入准确的电芯标称容量')

    def dateDetaile(self):
        self.tx.config(state='normal')
        self.tx.delete(1.0, TK.END)
        try:
            self.cell = Excel_date(self.date_path.get(),
                                self.nominal_capacity.get(), self.date_title_A.get(), self.date_title_B.get())
        except KeyError:
            messagebox.showinfo(title='警告', message='<号列标题>或<容量标题>不匹配，请检查Excel文件！')
            self.page_destory()

        except:
            messagebox.showinfo(title='警告', message='Excel文件已损坏或被加密！无法读取！')
            self.page_destory()

        else:
            self.max_capaciyt.set(self.cell.max_capacity)
            self.tx.insert('insert', self.cell.info)
            self.tx.config(state='disable')
            self.page_destory()
            self.page_draw = TK.Frame(self.page_draw1,
                                    width=440,
                                    height=450,
                                    bg='white')
            self.page_draw.grid(row=1, column=0, padx=2, pady=2)
            self.__plot(self.cell.cycle[:], self.cell.capacity[:], self.page_draw)

    def regre(self):
        if self.date_path.get() =='':
            messagebox.showinfo(title='警告', message='未选取数据文件，请选择！')
        else:
            self.tx1.config(state='normal')
            self.tx1.delete(1.0, TK.END)
            nominal_capacity = self.nominal_capacity.get()
            max_capaciyt = self.max_capaciyt.get()
            num = self.regre_num.get()

            def plot_regre(line):
                A = line.A
                B = line.B
                self.tx1.insert('insert', text)
                self.tx1.config(state='disable')
                if self.page_regre_draw:
                    self.page_regre_draw.destroy()
                    self.page_regre_draw = None
                self.page_regre_draw = TK.Frame(self.page_regre_draw1,
                                                width=440,
                                                height=450,
                                                bg='white')
                self.page_regre_draw.grid(row=2, column=1, padx=2, pady=2)
                self.__plot(x, y, self.page_regre_draw, A=A, B=B)
            if self.cell.cycle_latest < num:
                messagebox.showinfo(title='警告', message='拟合周数大于已有循环数，将拟合全部数据！')
                self.regre_num.set(0)
                num = 0
            if (num != 0) and (self.cell.cycle_latest >= self.regre_num.get()):
                x = self.cell.cycle[-num:]
                y = self.cell.capacity[-num:]
                line = LinearRegression(x, y, nominal_capacity, max_capaciyt)
                try:
                    line.predict()
                except ValueError as error:
                    messagebox.showinfo(title='警告', message='Excel文件数据有空白行，无法拟合，请删除后重试！')
                else:
                    text = ('拟合数据为最近 %d 周：\n' % (num)) + line.ifo
                    plot_regre(line)
            else:
                x = self.cell.cycle
                y = self.cell.capacity
                line = LinearRegression(x, y, nominal_capacity, max_capaciyt)
                try:
                    line.predict()
                except ValueError as error:
                    messagebox.showinfo(title='警告', message='Excel文件数据有空白行，无法拟合，请删除后重试！')
                else:
                    text = '全部数据进行拟合：\n' + line.ifo
                    plot_regre(line)

    def __plot(self, x, y, page, A=0.0, B=0.0):
        fig = plt.figure(figsize=(5, 4), dpi=100)
        # 利用子图画图
        axc = fig.add_subplot(111)
        axc.scatter(x, y, 25, 'red')
        # 如果存在拟合参数，绘制拟合线
        if A:
            x1 = np.arange(x[0], x[-1], 0.01)
            y1 = A * x1 + B
            axc.plot(x1, y1, 'blue')
        # 显示网格
        axc.grid(True)
        axc.set_xlabel('Cycle')
        axc.set_ylabel('Capacity(Ah)')
        # 创建画布控件
        canvas = FigureCanvasTkAgg(fig, master=page)  # A tk.DrawingArea.
        canvas.draw()
        # 显示画布控件
        canvas.get_tk_widget().pack()
        # 创建工具条控件
        toolbar = NavigationToolbar2Tk(canvas, page)
        toolbar.update()
        # 显示工具条控件
        canvas.get_tk_widget().pack()

        # 绑定快捷键函数
        def on_key_press(event):
            print("you pressed {}".format(event.key))
            key_press_handler(event, canvas, toolbar)

        # 调用快捷键函数
        canvas.mpl_connect("key_press_event", on_key_press)
        # return axc
    def page_destory(self):
        """
        如果已经绘图，删除之
        """
        if self.page_draw:
            self.page_draw.destroy()
            self.page_draw = None
        if self.page_regre_draw:
            self.page_regre_draw.destroy()
            self.page_regre_draw = None

    def help(self):
        messagebox.showinfo(title='使用说明', message="""
欢迎使用！
1、准确输入电芯标称容量值，用于计算相对标称容量的循环保持率。
2、正确输入<号列标题>和<容量标题>，使之与数据文件(Excel)中“循环序号”和“放电容量”的列标题一致。
3、循环数据需位于Excel文件中第一个sheet表。
4、确保选取的Excel文件未被加密软件加密。
5、确保Excel循环数据中间没有空单元格，否则无法进行拟合。
6、如果要删除异常数据点要整行删除，不留空单元格。
7、适当删除异常点可获得更好的拟合数据。
8、拟合周数表示要对最近多少周的数据进行拟合来预测循环情况。



© 2020 WangNan, All Rights Reserved.
QQ:517252897.""")

    def on_closing(self):
        if messagebox.askokcancel("退出", "是否要退出?"):
            self.page_date.destroy()
            self.page_ifo.destroy()
            self.page_draw1.destroy()
            self.page_regre_draw1.destroy()
            self.page_destory()
            self.win.after_cancel(self.solve)
            self.win.destroy()
            os._exit(0)



if __name__ == "__main__":
    Mainpage()
